فارسی

بر Suspense در React برای واکشی داده مسلط شوید. مدیریت اعلانی وضعیت‌های بارگذاری، بهبود UX با transitions، و مدیریت خطاها با Error Boundaries را بیاموزید.

مرزهای Suspense در React: نگاهی عمیق به مدیریت اعلانی وضعیت بارگذاری

در دنیای توسعه وب مدرن، ایجاد یک تجربه کاربری یکپارچه و واکنش‌گرا از اهمیت بالایی برخوردار است. یکی از چالش‌های مداومی که توسعه‌دهندگان با آن روبرو هستند، مدیریت وضعیت‌های بارگذاری (loading states) است. از واکشی داده برای پروفایل کاربر گرفته تا بارگذاری بخش جدیدی از یک اپلیکیشن، لحظات انتظار بسیار حیاتی هستند. به طور تاریخی، این کار شامل شبکه‌ای درهم‌تنیده از پرچم‌های بولی مانند isLoading، isFetching و hasError بود که در سراسر کامپوننت‌های ما پراکنده بودند. این رویکرد دستوری (imperative) کد ما را شلوغ می‌کند، منطق را پیچیده می‌سازد و منبع مکرر باگ‌هایی مانند شرایط رقابتی (race conditions) است.

اینجاست که React Suspense وارد می‌شود. Suspense که در ابتدا برای تقسیم کد (code-splitting) با React.lazy() معرفی شد، با React 18 قابلیت‌هایش به طور چشمگیری گسترش یافته و به یک مکانیزم قدرتمند و درجه یک برای مدیریت عملیات ناهمزمان، به ویژه واکشی داده، تبدیل شده است. Suspense به ما این امکان را می‌دهد که وضعیت‌های بارگذاری را به روشی اعلانی (declarative) مدیریت کنیم، که این امر اساساً نحوه نوشتن و استدلال در مورد کامپوننت‌هایمان را تغییر می‌دهد. به جای پرسیدن «آیا من در حال بارگذاری هستم؟»، کامپوننت‌های ما به سادگی می‌توانند بگویند: «من برای رندر شدن به این داده‌ها نیاز دارم. تا زمانی که منتظر هستم، لطفاً این UI جایگزین (fallback) را نشان بده.»

این راهنمای جامع شما را به سفری از روش‌های سنتی مدیریت وضعیت به پارادایم اعلانی React Suspense می‌برد. ما بررسی خواهیم کرد که مرزهای Suspense چه هستند، چگونه هم برای تقسیم کد و هم برای واکشی داده کار می‌کنند، و چگونه می‌توان UIهای بارگذاری پیچیده‌ای را سازماندهی کرد که کاربران شما را به جای ناامید کردن، خوشحال کند.

روش قدیمی: دردسر وضعیت‌های بارگذاری دستی

قبل از اینکه بتوانیم به طور کامل ظرافت Suspense را درک کنیم، ضروری است که مشکلی را که حل می‌کند، بفهمیم. بیایید به یک کامپوننت معمولی نگاه کنیم که داده‌ها را با استفاده از هوک‌های useEffect و useState واکشی می‌کند.

کامپوننتی را تصور کنید که نیاز به واکشی و نمایش داده‌های کاربر دارد:


import React, { useState, useEffect } from 'react';

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    // ریست کردن وضعیت برای userId جدید
    setIsLoading(true);
    setUser(null);
    setError(null);

    const fetchUser = async () => {
      try {
        const response = await fetch(`https://api.example.com/users/${userId}`);
        if (!response.ok) {
          throw new Error('پاسخ شبکه موفقیت‌آمیز نبود');
        }
        const data = await response.json();
        setUser(data);
      } catch (err) {
        setError(err);
      } finally {
        setIsLoading(false);
      }
    };

    fetchUser();
  }, [userId]); // واکشی مجدد هنگام تغییر userId

  if (isLoading) {
    return <p>در حال بارگذاری پروفایل...</p>;
  }

  if (error) {
    return <p>خطا: {error.message}</p>;
  }

  return (
    <div>
      <h1>{user.name}</h1>
      <p>ایمیل: {user.email}</p>
    </div>
  );
}

این الگو کاربردی است، اما چندین نقطه ضعف دارد:

ورود React Suspense: یک تغییر پارادایم

Suspense این مدل را کاملاً برعکس می‌کند. به جای اینکه کامپوننت وضعیت بارگذاری را به صورت داخلی مدیریت کند، وابستگی خود به یک عملیات ناهمزمان را مستقیماً به React اعلام می‌کند. اگر داده‌ای که نیاز دارد هنوز در دسترس نباشد، کامپوننت رندر را «معلق» (suspend) می‌کند.

وقتی یک کامپوننت معلق می‌شود، React در درخت کامپوننت‌ها به سمت بالا حرکت می‌کند تا نزدیک‌ترین مرز Suspense (Suspense Boundary) را پیدا کند. یک مرز Suspense کامپوننتی است که شما در درخت خود با استفاده از <Suspense> تعریف می‌کنید. این مرز سپس یک UI جایگزین (مانند یک اسپینر یا یک اسکلت لودر) را رندر می‌کند تا زمانی که تمام کامپوننت‌های درون آن وابستگی‌های داده‌ای خود را برطرف کنند.

ایده اصلی این است که وابستگی داده را در کنار کامپوننتی که به آن نیاز دارد قرار دهیم، در حالی که UI بارگذاری را در سطح بالاتری از درخت کامپوننت متمرکز کنیم. این کار منطق کامپوننت را تمیز می‌کند و به شما کنترل قدرتمندی بر تجربه بارگذاری کاربر می‌دهد.

چگونه یک کامپوننت "Suspend" می‌شود؟

جادوی پشت Suspense در الگویی نهفته است که ممکن است در ابتدا غیرعادی به نظر برسد: پرتاب کردن یک Promise. یک منبع داده سازگار با Suspense به این صورت کار می‌کند:

  1. وقتی یک کامپوننت داده‌ای را درخواست می‌کند، منبع داده بررسی می‌کند که آیا آن داده را در حافظه پنهان (cache) دارد یا خیر.
  2. اگر داده در دسترس باشد، آن را به صورت همزمان برمی‌گرداند.
  3. اگر داده در دسترس نباشد (یعنی در حال واکشی باشد)، منبع داده Promise ای را که نماینده درخواست واکشی در حال انجام است، پرتاب (throw) می‌کند.

React این Promise پرتاب شده را می‌گیرد. این کار باعث از کار افتادن اپلیکیشن شما نمی‌شود. در عوض، آن را به عنوان یک سیگنال تفسیر می‌کند: «این کامپوننت هنوز آماده رندر شدن نیست. آن را متوقف کن و به دنبال یک مرز Suspense در بالای آن بگرد تا یک جایگزین نشان دهی.» پس از اینکه Promise برطرف (resolve) شد، React دوباره تلاش می‌کند تا کامپوننت را رندر کند، که این بار داده‌های خود را دریافت کرده و با موفقیت رندر می‌شود.

مرز <Suspense>: اعلان‌گر UI بارگذاری شما

کامپوننت <Suspense> قلب این الگو است. استفاده از آن فوق‌العاده ساده است و یک پراپ الزامی به نام fallback می‌گیرد.


import { Suspense } from 'react';

function App() {
  return (
    <div>
      <h1>اپلیکیشن من</h1>
      <Suspense fallback={<p>در حال بارگذاری محتوا...</p>}>
        <SomeComponentThatFetchesData />
      </Suspense>
    </div>
  );
}

در این مثال، اگر SomeComponentThatFetchesData معلق شود، کاربر پیام «در حال بارگذاری محتوا...» را تا زمانی که داده‌ها آماده شوند، خواهد دید. fallback می‌تواند هر گره معتبر React باشد، از یک رشته ساده تا یک کامپوننت اسکلتی پیچیده.

کاربرد کلاسیک: تقسیم کد (Code Splitting) با React.lazy()

معروف‌ترین استفاده از Suspense برای تقسیم کد است. این ویژگی به شما امکان می‌دهد بارگذاری جاوا اسکریپت یک کامپوننت را تا زمانی که واقعاً به آن نیاز است به تعویق بیندازید.


import React, { Suspense, lazy } from 'react';

// کد این کامپوننت در باندل اولیه نخواهد بود.
const HeavyComponent = lazy(() => import('./HeavyComponent'));

function App() {
  return (
    <div>
      <h2>محتوایی که بلافاصله بارگذاری می‌شود</h2>
      <Suspense fallback={<div>در حال بارگذاری کامپوننت...</div>}>
        <HeavyComponent />
      </Suspense>
    </div>
  );
}

در اینجا، React تنها زمانی جاوا اسکریپت HeavyComponent را واکشی می‌کند که برای اولین بار تلاش کند آن را رندر کند. در حین واکشی و تجزیه آن، fallback مربوط به Suspense نمایش داده می‌شود. این یک تکنیک قدرتمند برای بهبود زمان بارگذاری اولیه صفحه است.

مرز مدرن: واکشی داده با Suspense

در حالی که React مکانیزم Suspense را فراهم می‌کند، یک کلاینت مشخص برای واکشی داده ارائه نمی‌دهد. برای استفاده از Suspense برای واکشی داده، به یک منبع داده نیاز دارید که با آن یکپارچه شود (یعنی منبعی که هنگام در انتظار بودن داده، یک Promise پرتاب کند).

فریم‌ورک‌هایی مانند Relay و Next.js پشتیبانی داخلی و درجه یک از Suspense دارند. کتابخانه‌های محبوب واکشی داده مانند TanStack Query (قبلاً React Query) و SWR نیز پشتیبانی آزمایشی یا کامل از آن را ارائه می‌دهند.

برای درک مفهوم، بیایید یک پوشش مفهومی و بسیار ساده در اطراف API fetch ایجاد کنیم تا آن را با Suspense سازگار کنیم. توجه: این یک مثال ساده‌شده برای اهداف آموزشی است و برای محیط پروداکشن آماده نیست. این مثال فاقد کشینگ مناسب و پیچیدگی‌های مدیریت خطا است.


// data-fetcher.js
// یک کش ساده برای ذخیره نتایج
const cache = new Map();

export function fetchData(url) {
  if (!cache.has(url)) {
    cache.set(url, { status: 'pending', promise: fetchAndCache(url) });
  }

  const record = cache.get(url);

  if (record.status === 'pending') {
    throw record.promise; // جادو اینجاست!
  }
  if (record.status === 'error') {
    throw record.error;
  }
  if (record.status === 'success') {
    return record.data;
  }
}

async function fetchAndCache(url) {
  try {
    const response = await fetch(url);
    if (!response.ok) {
      throw new Error(`واکشی با وضعیت ${response.status} ناموفق بود`);
    }
    const data = await response.json();
    cache.set(url, { status: 'success', data });
  } catch (e) {
    cache.set(url, { status: 'error', error: e });
  }
}

این پوشش یک وضعیت ساده برای هر URL حفظ می‌کند. وقتی fetchData فراخوانی می‌شود، وضعیت را بررسی می‌کند. اگر در حالت انتظار (pending) باشد، promise را پرتاب می‌کند. اگر موفقیت‌آمیز باشد، داده‌ها را برمی‌گرداند. حال، بیایید کامپوننت UserProfile خود را با استفاده از این روش بازنویسی کنیم.


// UserProfile.js
import React, { Suspense } from 'react';
import { fetchData } from './data-fetcher';

// کامپوننتی که واقعاً از داده‌ها استفاده می‌کند
function ProfileDetails({ userId }) {
  // تلاش برای خواندن داده. اگر آماده نباشد، suspend خواهد شد.
  const user = fetchData(`https://api.example.com/users/${userId}`);

  return (
    <div>
      <h1>{user.name}</h1>
      <p>ایمیل: {user.email}</p>
    </div>
  );
}

// کامپوننت والد که UI وضعیت بارگذاری را تعریف می‌کند
export function UserProfile({ userId }) {
  return (
    <Suspense fallback={<p>در حال بارگذاری پروفایل...</p>}>
      <ProfileDetails userId={userId} />
    </Suspense>
  );
}

به تفاوت نگاه کنید! کامپوننت ProfileDetails تمیز و صرفاً بر روی رندر کردن داده‌ها متمرکز است. هیچ وضعیت isLoading یا error ندارد. به سادگی داده‌هایی را که نیاز دارد درخواست می‌کند. مسئولیت نمایش یک نشانگر بارگذاری به کامپوننت والد، یعنی UserProfile، منتقل شده است که به صورت اعلانی مشخص می‌کند در حین انتظار چه چیزی نمایش داده شود.

هماهنگ‌سازی وضعیت‌های بارگذاری پیچیده

قدرت واقعی Suspense زمانی آشکار می‌شود که شما UIهای پیچیده‌ای با چندین وابستگی ناهمزمان می‌سازید.

مرزهای Suspense تودرتو برای یک UI پلکانی

شما می‌توانید مرزهای Suspense را به صورت تودرتو قرار دهید تا یک تجربه بارگذاری دقیق‌تر ایجاد کنید. یک صفحه داشبورد را با یک نوار کناری، یک ناحیه محتوای اصلی و لیستی از فعالیت‌های اخیر تصور کنید. هر یک از این‌ها ممکن است به واکشی داده خود نیاز داشته باشد.


function DashboardPage() {
  return (
    <div>
      <h1>داشبورد</h1>
      <div className="layout">
        <Suspense fallback={<p>در حال بارگذاری ناوبری...</p>}>
          <Sidebar />
        </Suspense>

        <main>
          <Suspense fallback={<ProfileSkeleton />}>
            <MainContent />
          </Suspense>

          <Suspense fallback={<ActivityFeedSkeleton />}>
            <ActivityFeed />
          </Suspense>
        </main>
      </div>
    </div>
  );
}

با این ساختار:

این به شما امکان می‌دهد محتوای مفید را در سریع‌ترین زمان ممکن به کاربر نشان دهید و عملکرد درک‌شده را به طور چشمگیری بهبود بخشید.

جلوگیری از "پرش" (Popcorning) در UI

گاهی اوقات، رویکرد پلکانی می‌تواند منجر به یک اثر ناخوشایند شود که در آن چندین اسپینر به سرعت پشت سر هم ظاهر و ناپدید می‌شوند، اثری که اغلب «popcorning» نامیده می‌شود. برای حل این مشکل، می‌توانید مرز Suspense را به سطح بالاتری در درخت منتقل کنید.


function DashboardPage() {
  return (
    <div>
      <h1>داشبورد</h1>
      <Suspense fallback={<DashboardSkeleton />}>
        <div className="layout">
          <Sidebar />
          <main>
            <MainContent />
            <ActivityFeed />
          </main>
        </div>
      </Suspense>
    </div>
  );
}

در این نسخه، یک DashboardSkeleton واحد نمایش داده می‌شود تا زمانی که *تمام* کامپوننت‌های فرزند (Sidebar، MainContent، ActivityFeed) داده‌های خود را آماده کنند. سپس کل داشبورد به یکباره ظاهر می‌شود. انتخاب بین مرزهای تودرتو و یک مرز واحد در سطح بالاتر، یک تصمیم طراحی UX است که Suspense پیاده‌سازی آن را بسیار ساده می‌کند.

مدیریت خطا با مرزهای خطا (Error Boundaries)

Suspense وضعیت *در انتظار* (pending) یک promise را مدیریت می‌کند، اما در مورد وضعیت *رد شده* (rejected) چطور؟ اگر promise پرتاب شده توسط یک کامپوننت رد شود (مثلاً یک خطای شبکه)، با آن مانند هر خطای رندر دیگری در React رفتار می‌شود.

راه حل استفاده از مرزهای خطا (Error Boundaries) است. یک Error Boundary یک کامپوننت کلاسی است که یک متد چرخه حیات ویژه به نام componentDidCatch() یا یک متد استاتیک به نام getDerivedStateFromError() را تعریف می‌کند. این کامپوننت خطاهای جاوا اسکریپت را در هر جای درخت کامپوننت فرزند خود می‌گیرد، آن خطاها را ثبت می‌کند و یک UI جایگزین نمایش می‌دهد.

در اینجا یک کامپوننت ساده Error Boundary آورده شده است:


import React from 'react';

class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false, error: null };
  }

  static getDerivedStateFromError(error) {
    // به‌روزرسانی وضعیت تا رندر بعدی UI جایگزین را نشان دهد.
    return { hasError: true, error: error };
  }

  componentDidCatch(error, errorInfo) {
    // همچنین می‌توانید خطا را در یک سرویس گزارش خطا ثبت کنید
    console.error("یک خطا گرفته شد:", error, errorInfo);
  }

  render() {
    if (this.state.hasError) {
      // می‌توانید هر UI جایگزین سفارشی را رندر کنید
      return <h1>مشکلی پیش آمد. لطفاً دوباره تلاش کنید.</h1>;
    }

    return this.props.children; 
  }
}

سپس می‌توانید Error Boundaries را با Suspense ترکیب کنید تا یک سیستم قوی ایجاد کنید که هر سه وضعیت: در انتظار، موفقیت و خطا را مدیریت کند.


import { Suspense } from 'react';
import ErrorBoundary from './ErrorBoundary';
import { UserProfile } from './UserProfile';

function App() {
  return (
    <div>
      <h2>اطلاعات کاربر</h2>
      <ErrorBoundary>
        <Suspense fallback={<p>در حال بارگذاری...</p>}>
          <UserProfile userId={123} />
        </Suspense>
      </ErrorBoundary>
    </div>
  );
}

با این الگو، اگر واکشی داده در داخل UserProfile موفقیت‌آمیز باشد، پروفایل نمایش داده می‌شود. اگر در حالت انتظار باشد، fallback مربوط به Suspense نمایش داده می‌شود. اگر ناموفق باشد، fallback مربوط به Error Boundary نمایش داده می‌شود. منطق به صورت اعلانی، ترکیبی (compositional) و قابل درک است.

Transitions: کلید به‌روزرسانی‌های غیرمسدودکننده UI

یک قطعه نهایی برای این پازل وجود دارد. تعامل کاربری را در نظر بگیرید که یک واکشی داده جدید را آغاز می‌کند، مانند کلیک بر روی دکمه «بعدی» برای مشاهده پروفایل کاربر دیگر. با تنظیمات بالا، لحظه‌ای که دکمه کلیک شده و پراپ userId تغییر می‌کند، کامپوننت UserProfile دوباره معلق می‌شود. این به این معنی است که پروفایل فعلی ناپدید شده و با fallback بارگذاری جایگزین می‌شود. این می‌تواند ناگهانی و مخرب به نظر برسد.

اینجاست که transitions وارد می‌شوند. Transitions یک ویژگی جدید در React 18 است که به شما امکان می‌دهد برخی از به‌روزرسانی‌های وضعیت را به عنوان غیرفوری علامت‌گذاری کنید. وقتی یک به‌روزرسانی وضعیت در یک transition قرار می‌گیرد، React به نمایش UI قدیمی (محتوای کهنه) ادامه می‌دهد در حالی که محتوای جدید را در پس‌زمینه آماده می‌کند. تنها زمانی به‌روزرسانی UI را اعمال می‌کند که محتوای جدید آماده نمایش باشد.

API اصلی برای این کار هوک useTransition است.


import React, { useState, useTransition, Suspense } from 'react';
import { UserProfile } from './UserProfile';

function ProfileSwitcher() {
  const [userId, setUserId] = useState(1);
  const [isPending, startTransition] = useTransition();

  const handleNextClick = () => {
    startTransition(() => {
      setUserId(id => id + 1);
    });
  };

  return (
    <div>
      <button onClick={handleNextClick} disabled={isPending}>
        کاربر بعدی
      </button>

      {isPending && <span> در حال بارگذاری پروفایل جدید...</span>}

      <ErrorBoundary>
        <Suspense fallback={<p>در حال بارگذاری پروفایل اولیه...</p>}>
          <UserProfile userId={userId} />
        </Suspense>
      </ErrorBoundary>
    </div>
  );
}

حالا اتفاقی که می‌افتد این است:

  1. پروفایل اولیه برای userId: 1 بارگذاری می‌شود و fallback مربوط به Suspense را نشان می‌دهد.
  2. کاربر روی «کاربر بعدی» کلیک می‌کند.
  3. فراخوانی setUserId در startTransition قرار گرفته است.
  4. React شروع به رندر کردن UserProfile با userId جدید یعنی 2 در حافظه می‌کند. این باعث معلق شدن آن می‌شود.
  5. نکته حیاتی: به جای نمایش fallback مربوط به Suspense، React UI قدیمی (پروفایل کاربر 1) را روی صفحه نگه می‌دارد.
  6. مقدار بولی isPending که توسط useTransition برگردانده می‌شود، true می‌شود و به ما امکان می‌دهد یک نشانگر بارگذاری ظریف و درون‌خطی را بدون حذف محتوای قدیمی نمایش دهیم.
  7. هنگامی که داده‌های کاربر 2 واکشی شد و UserProfile توانست با موفقیت رندر شود، React به‌روزرسانی را اعمال می‌کند و پروفایل جدید به طور یکپارچه ظاهر می‌شود.

Transitions لایه نهایی کنترل را فراهم می‌کنند و شما را قادر می‌سازند تا تجربیات بارگذاری پیچیده و کاربرپسندی بسازید که هرگز ناخوشایند نباشند.

بهترین شیوه‌ها و ملاحظات کلی

نتیجه‌گیری

React Suspense چیزی بیش از یک ویژگی جدید را نشان می‌دهد؛ این یک تحول بنیادی در نحوه رویکرد ما به ناهمزمانی در اپلیکیشن‌های React است. با فاصله گرفتن از پرچم‌های بارگذاری دستی و دستوری و استقبال از یک مدل اعلانی، می‌توانیم کامپوننت‌هایی بنویسیم که تمیزتر، مقاوم‌تر و ترکیب‌پذیرتر باشند.

با ترکیب <Suspense> برای وضعیت‌های در انتظار، Error Boundaries برای وضعیت‌های شکست، و useTransition برای به‌روزرسانی‌های یکپارچه، شما یک جعبه ابزار کامل و قدرتمند در اختیار دارید. شما می‌توانید همه چیز را از اسپینرهای بارگذاری ساده تا نمایش‌های داشبورد پیچیده و پلکانی با کدی حداقل و قابل پیش‌بینی سازماندهی کنید. با شروع به ادغام Suspense در پروژه‌های خود، متوجه خواهید شد که نه تنها عملکرد و تجربه کاربری اپلیکیشن شما را بهبود می‌بخشد، بلکه منطق مدیریت وضعیت شما را به طور چشمگیری ساده می‌کند و به شما امکان می‌دهد بر روی آنچه واقعاً مهم است تمرکز کنید: ساختن ویژگی‌های عالی.